feat: add evaluate_flags() API for single-call flag evaluation#137
feat: add evaluate_flags() API for single-call flag evaluation#137
Conversation
Add Client#evaluate_flags(distinct_id, ...) returning a FeatureFlagEvaluations snapshot, and a flags: option on capture so a single /flags call can power both flag branching and event enrichment per request. The snapshot exposes is_enabled, get_flag, get_flag_payload, plus only_accessed / only([keys]) filter helpers. flag_keys: scopes the underlying /flags request itself. is_enabled and get_flag fire $feature_flag_called events with full metadata (id, version, reason, request_id), deduped through the existing per-distinct_id cache. get_flag_payload does not record access or fire an event. The dedup + capture in get_feature_flag_result is extracted into _capture_feature_flag_called_if_needed and shared between the existing path and the snapshot's access-recording. Existing is_feature_enabled, get_feature_flag, get_feature_flag_result, get_feature_flag_payload, and capture(send_feature_flags:) continue to work unchanged. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
Mirrors the changes that landed on the Python PR after review: - only_accessed returns an empty snapshot when nothing has been accessed (drops the warn-and-fall-back-to-all-flags behavior — that was a misguided safety net that surprised callers in the early-pre-access pattern). - capture(flags:, send_feature_flags:) now warns when both are passed, uses the snapshot, and ignores send_feature_flags. The snapshot guarantees the event carries the values branched on; the precedence was previously implicit. - $feature_flag_called events now carry response-level errors (errors_while_computing_flags, quota_limited) combined with per-flag errors (flag_missing) as a comma-joined $feature_flag_error, matching the granularity of the legacy single-flag path. - capture_exception now accepts a flags: kwarg and forwards it to the inner capture() so $exception events can carry the same flag context as other events. - Phase 2 deprecation warnings ship alongside Phase 1: is_feature_enabled, get_feature_flag, get_feature_flag_result, get_feature_flag_payload, and capture(send_feature_flags:) emit Kernel.warn(..., category: :deprecated) pointing at evaluate_flags(). Public methods bypass each other internally (via a new private _get_feature_flag_result) so a single user-level call emits exactly one warning. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
| @flags.keys | ||
| end | ||
|
|
||
| def is_enabled(key) # rubocop:disable Naming/PredicateName |
There was a problem hiding this comment.
enabled?(key) feels more Ruby idiomatic here
| key = key.to_s | ||
| flag = @flags[key] | ||
| response = flag&.enabled ? true : false | ||
| _record_access(key, flag, response) |
There was a problem hiding this comment.
For variant flags, this will emit the $feature_flag_called event with $feature/variant-flag: true | false instead of the variant. This means is_enabled and get_flag will both emit $feature_flag_called due to the different response.
There was a problem hiding this comment.
similarly, response will be false instead of nil if the flag is missing
| end | ||
|
|
||
| @before_send = opts[:before_send] | ||
| @feature_flags_log_warnings = opts.key?(:feature_flags_log_warnings) ? opts[:feature_flags_log_warnings] : true |
There was a problem hiding this comment.
Do we need this, or should we gate via log level?
config.log_level = :debugsimilar to PostHog/posthog-android#498 (comment)
| Kernel.warn( | ||
| '`send_feature_flags` is deprecated and will be removed in a future major version. ' \ | ||
| 'Pass a `flags` snapshot from `client.evaluate_flags(...)` instead — it avoids a ' \ | ||
| 'second `/flags` request per capture and guarantees the event carries the exact ' \ | ||
| 'flag values your code branched on.', | ||
| category: :deprecated, uplevel: 1 | ||
| ) |
There was a problem hiding this comment.
Hmm, we don't have a great way of presenting deprecations in this SDK.
Realistically I don't know if anyone is going to see this. Maybe we drop the :deprecated category and only log once per unique method? ActiveSupport has it's own deprecation API, so I don't think we'd be stepping outside of what's acceptable.
| errors_while_computing = false | ||
| quota_limited = false | ||
|
|
||
| unless only_evaluate_locally |
There was a problem hiding this comment.
We might have matched all the flags locally already, but unless only_evaluate_locally is true we'll still hit /flags
| key: key_str, | ||
| enabled: ff.enabled ? true : false, | ||
| variant: ff.variant, | ||
| payload: ff.payload, |
There was a problem hiding this comment.
We should normalize the payload to a deserialized type
| flags_response = @feature_flags_poller.get_flags( | ||
| distinct_id, groups, person_properties, group_properties, flag_keys | ||
| ) |
There was a problem hiding this comment.
We accept disable_geoip as a param but don't do anything with it. get_flags doesn't look to support it.
| poller_flags_by_key = @feature_flags_poller.feature_flags_by_key || {} | ||
|
|
||
| poller_flags_by_key.each do |key, definition| | ||
| next if flag_keys && !flag_keys.map(&:to_s).include?(key.to_s) |
There was a problem hiding this comment.
Prefer using a set here
| 'capture(); using `flags` and ignoring `send_feature_flags`.' | ||
| ) | ||
| end | ||
| snapshot_props = attrs[:flags]._get_event_properties |
There was a problem hiding this comment.
We should guard against the NoMethodError here
| # Internal: implementation of {#get_feature_flag_result}, called by both the | ||
| # public method and the legacy `is_feature_enabled` / `get_feature_flag` | ||
| # paths. Bypassing the public wrapper avoids cascading deprecation warnings. | ||
| def _get_feature_flag_result( |
There was a problem hiding this comment.
This is in the public section, can it be moved to private?
Snapshot: - Rename is_enabled → enabled? for Ruby idiom (the legacy client-level is_feature_enabled keeps its name but is now deprecated). - Canonicalize the recorded $feature_flag_response across enabled?/get_flag so a variant flag accessed both ways collapses to one $feature_flag_called event (with the variant value) instead of two with mismatched booleans. Same fix for missing flags: response is nil regardless of which method was called. Client / poller: - Drop the feature_flags_log_warnings config option in favor of standard log-level gating: warnings flow through logger.warn so callers silence them via their existing log config (mirrors the posthog-android feedback). - Skip the remote /flags call when flag_keys are all locally resolved. Without flag_keys we still hit the server because we don't know about server-side-only flags. - Forward disable_geoip to the /flags request body as geoip_disable. - Use a Set for flag_keys lookup to keep the per-flag check O(1). - Normalize remote and local-eval payloads via FeatureFlagResult.parse_payload (made public — same contract as FeatureFlagResult.from_value_and_payload uses internally). - Guard capture(flags:) against non-snapshot values: log a warning and ignore instead of raising NoMethodError. - Move _get_feature_flag_result into the private section. Deprecation warnings: - Drop the :deprecated category — Ruby suppresses those by default since 2.7.2 and most users wouldn't see them. Use Kernel.warn with a [posthog-ruby] DEPRECATION prefix; standard log-level / IO silencing still works. - Emit each deprecation at most once per (method, client) pair via Concurrent::Set so high-volume callers don't pay a per-call cost. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
The changeset was written before the is_enabled → enabled? rename that came out of Dustin's review; align the example snippet and description so the generated CHANGELOG entry uses the final API naming. Also softens "DeprecationWarning" to "one-time deprecation warning per method" since we dropped the :deprecated category in favor of always-visible Kernel.warn. Generated-By: PostHog Code Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
Summary
Client#evaluate_flags(distinct_id, …)returning aFeatureFlagEvaluationssnapshot — one/flagsround-trip powers both branching and event enrichment.is_enabled/get_flag/get_flag_payloadfor branching, plusonly_accessed/only([keys])to narrow what gets attached to a captured event.flags:option oncaptureandcapture_exception— when present, attaches$feature/<key>and$active_feature_flagsfrom the snapshot without any extra/flagscall.flag_keys:onevaluate_flagsscopes the underlying/flagsrequest itself (sent asflag_keys_to_evaluate).is_feature_enabled,get_feature_flag,get_feature_flag_result,get_feature_flag_payload, andcapture(send_feature_flags:)— they keep working but now emit aDeprecationWarningpointing atevaluate_flags(). Removal is planned for the next major.feature_flags_log_warnings:client option (defaulttrue) to silence the snapshot's filter-helper warnings.References
RFC: https://github.com/PostHog/requests-for-comments-internal/pull/1020 · mirrors posthog-python#539 and posthog-js#3476.
Design decisions
is_enabledreturnsfalsefor unknown flags,get_flagreturnsnil— matches the legacy single-flag methods so existing branching code is structurally interchangeable.get_flag_payloaddeliberately does not record access or fire$feature_flag_called— payload-only reads shouldn't count as an exposure.only_accessed()returns an empty snapshot when nothing has been accessed (it honors its name) — pre-access flags first if you want a populated result.accessedset, so calls on a clone don't back-propagate exposures into the parent.Hoststruct (two lambdas:capture_flag_called_event_if_needed,log_warning) is passed to the snapshot instead of a back-reference to the fullClient— keeps the snapshot decoupled and testable.locally_evaluated: true, reason"Evaluated locally", and$feature_flag_definitions_loaded_aton emitted events, matching the existing single-flag local path.errorsWhileComputingFlags,quotaLimited) and per-flag errors (flag_missing) are combined into a comma-joined$feature_flag_errorso each event keeps the granular error code(s).flags:andsend_feature_flags:tocaptureuses the snapshot and warns — the snapshot guarantees the event carries the same values your code branched on.is_feature_enabledandget_feature_flagcall a private_get_feature_flag_resultdirectly) so a single user-level call emits exactly one warning, not a cascade.Phase 2 follow-ups
evaluate_flags().flagssnapshot on a request-scoped context so auto-captured exceptions inherit the same flag context as manual captures.All tests + lint pass on Ruby 3.2 / 3.3 / 3.4 (380 examples).
Created with PostHog Code